/*  Global class for simulating the movement of particle through a 1km wind grid

    credit: All the credit for this work goes to: https://github.com/cambecc for creating the repo:
      https://github.com/cambecc/earth. The majority of this code is directly take nfrom there, since its awesome.

    This class takes a canvas element and an array of data (1km GFS from http://www.emc.ncep.noaa.gov/index.php?branch=GFS)
    and then uses a mercator (forward/reverse) projection to correctly map wind vectors in "map space".

    The "start" method takes the bounds of the map at its current extent and starts the whole gridding,
    interpolation and animation process.
*/

let Windy = function (params) {
  let VELOCITY_SCALE = 0.021; // scale for wind velocity (completely arbitrary--this value looks nice)
  let INTENSITY_SCALE_STEP = 10; // step size of particle intensity color scale
  let MAX_WIND_INTENSITY = 40; // wind velocity at which particle intensity is maximum (m/s)
  let MAX_PARTICLE_AGE = 100; // max number of frames a particle is drawn before regeneration
  let PARTICLE_LINE_WIDTH = 0.6; // line width of a drawn particle
  let PARTICLE_MULTIPLIER = 1 / 30; // particle count scalar (completely arbitrary--this values looks nice)
  let PARTICLE_REDUCTION = 0.5; // reduce particle count to this much of normal for mobile devices
  let FRAME_RATE = 60; // desired milliseconds per frame
  let BOUNDARY = 0.45;

  let NULL_WIND_VECTOR = [NaN, NaN, null]; // singleton for no wind in the form: [u, v, magnitude]
  let TRANSPARENT_BLACK = [255, 0, 0, 0];

  let τ = 2 * Math.PI;
  let H = Math.pow(10, -5.2);
  console.log(BOUNDARY, TRANSPARENT_BLACK, τ, H);

  // interpolation for vectors like wind (u,v,m)
  let bilinearInterpolateVector = function (x, y, g00, g10, g01, g11) {
    let rx = 1 - x;
    let ry = 1 - y;
    let a = rx * ry,
      b = x * ry,
      c = rx * y,
      d = x * y;
    let u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
    let v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
    return [u, v, Math.sqrt(u * u + v * v)];
  };

  let createWindBuilder = function (uComp, vComp) {
    let uData = uComp.data,
      vData = vComp.data;
    return {
      header: uComp.header,
      //recipe: recipeFor("wind-" + uComp.header.surface1Value),
      data: function (i) {
        return [uData[i], vData[i]];
      },
      interpolate: bilinearInterpolateVector
    };
  };

  let createBuilder = function (data) {
    let uComp = null,
      vComp = null,
      scalar = null;
    console.log(scalar);

    data.forEach(function (record) {
      switch (record.header.parameterCategory + "," + record.header.parameterNumber) {
        case "2,2":
          uComp = record;
          break;
        case "2,3":
          vComp = record;
          break;
        default:
          scalar = record;
      }
    });

    return createWindBuilder(uComp, vComp);
  };

  let buildGrid = function (data, callback) {
    let builder = createBuilder(data);

    let header = builder.header;
    let λ0 = header.lo1,
      φ0 = header.la1; // the grid's origin (e.g., 0.0E, 90.0N)
    let Δλ = header.dx,
      Δφ = header.dy; // distance between grid points (e.g., 2.5 deg lon, 2.5 deg lat)
    let ni = header.nx,
      nj = header.ny; // number of grid points W-E and N-S (e.g., 144 x 73)
    let date = new Date(header.refTime);
    date.setHours(date.getHours() + header.forecastTime);

    // Scan mode 0 assumed. Longitude increases from λ0, and latitude decreases from φ0.
    // http://www.nco.ncep.noaa.gov/pmb/docs/grib2/grib2_table3-4.shtml
    let grid = [],
      p = 0;
    let isContinuous = Math.floor(ni * Δλ) >= 360;
    for (let j = 0; j < nj; j++) {
      let row = [];
      for (let i = 0; i < ni; i++, p++) {
        row[i] = builder.data(p);
      }
      if (isContinuous) {
        // For wrapped grids, duplicate first column as last column to simplify interpolation logic
        row.push(row[0]);
      }
      grid[j] = row;
    }

    function interpolate(λ, φ) {
      let i = floorMod(λ - λ0, 360) / Δλ; // calculate longitude index in wrapped range [0, 360)
      let j = (φ0 - φ) / Δφ; // calculate latitude index in direction +90 to -90

      let fi = Math.floor(i),
        ci = fi + 1;
      let fj = Math.floor(j),
        cj = fj + 1;

      let row;
      if ((row = grid[fj])) {
        let g00 = row[fi];
        let g10 = row[ci];
        if (isValue(g00) && isValue(g10) && (row = grid[cj])) {
          let g01 = row[fi];
          let g11 = row[ci];
          if (isValue(g01) && isValue(g11)) {
            // All four points found, so interpolate the value.
            return builder.interpolate(i - fi, j - fj, g00, g10, g01, g11);
          }
        }
      }
      return null;
    }
    callback({
      date: date,
      interpolate: interpolate
    });
  };

  /**
   * @returns {Boolean} true if the specified value is not null and not undefined.
   */
  let isValue = function (x) {
    return x !== null && x !== undefined;
  };

  /**
   * @returns {Number} returns remainder of floored division, i.e., floor(a / n). Useful for consistent modulo
   *          of negative numbers. See http://en.wikipedia.org/wiki/Modulo_operation.
   */
  let floorMod = function (a, n) {
    return a - n * Math.floor(a / n);
  };

  /**
   * @returns {Number} the value x clamped to the range [low, high].
   */
  let clamp = function (x, range) {
    return Math.max(range[0], Math.min(x, range[1]));
  };

  /**
   * @returns {Boolean} true if agent is probably a mobile device. Don't really care if this is accurate.
   */
  let isMobile = function () {
    return true;
    return /android|blackberry|iemobile|ipad|iphone|ipod|opera mini|webos/i.test(navigator.userAgent);
  };

  /**
   * Calculate distortion of the wind vector caused by the shape of the projection at point (x, y). The wind
   * vector is modified in place and returned by this function.
   */
  let distort = function (projection, λ, φ, x, y, scale, wind, windy) {
    let u = wind[0] * scale;
    let v = wind[1] * scale;
    let d = distortion(projection, λ, φ, x, y, windy);

    // Scale distortion vectors by u and v, then add.
    wind[0] = d[0] * u + d[2] * v;
    wind[1] = d[1] * u + d[3] * v;
    return wind;
  };

  let distortion = function (projection, λ, φ, x, y, windy) {
    let τ = 2 * Math.PI;
    let H = Math.pow(10, -5.2);
    let hλ = λ < 0 ? H : -H;
    let hφ = φ < 0 ? H : -H;

    let pλ = project(φ, λ + hλ, windy);
    let pφ = project(φ + hφ, λ, windy);

    // Meridian scale factor (see Snyder, equation 4-3), where R = 1. This handles issue where length of 1º λ
    // changes depending on φ. Without this, there is a pinching effect at the poles.
    let k = Math.cos((φ / 360) * τ);
    return [(pλ[0] - x) / hλ / k, (pλ[1] - y) / hλ / k, (pφ[0] - x) / hφ, (pφ[1] - y) / hφ];
  };

  let createField = function (columns, bounds, callback) {
    /**
     * @returns {Array} wind vector [u, v, magnitude] at the point (x, y), or [NaN, NaN, null] if wind
     *          is undefined at that point.
     */
    function field(x, y) {
      let column = columns[Math.round(x)];
      return (column && column[Math.round(y)]) || NULL_WIND_VECTOR;
    }

    // Frees the massive "columns" array for GC. Without this, the array is leaked (in Chrome) each time a new
    // field is interpolated because the field closure's context is leaked, for reasons that defy explanation.
    field.release = function () {
      columns = [];
    };

    field.randomize = function (o) {
      // UNDONE: this method is terrible
      let x, y;
      let safetyNet = 0;
      do {
        x = Math.round(Math.floor(Math.random() * bounds.width) + bounds.x);
        y = Math.round(Math.floor(Math.random() * bounds.height) + bounds.y);
      } while (field(x, y)[2] === null && safetyNet++ < 30);
      o.x = x;
      o.y = y;
      return o;
    };

    //field.overlay = mask.imageData;
    //return field;
    callback(bounds, field);
  };

  let buildBounds = function (bounds, width, height) {
    let upperLeft = bounds[0];
    let lowerRight = bounds[1];
    let x = Math.round(upperLeft[0]); //Math.max(Math.floor(upperLeft[0], 0), 0);
    let y = Math.max(Math.floor(upperLeft[1], 0), 0);
    let xMax = Math.min(Math.ceil(lowerRight[0], width), width - 1);
    let yMax = Math.min(Math.ceil(lowerRight[1], height), height - 1);
    return { x: x, y: y, xMax: width, yMax: yMax, width: width, height: height };
  };

  let deg2rad = function (deg) {
    return (deg / 180) * Math.PI;
  };

  let rad2deg = function (ang) {
    return ang / (Math.PI / 180.0);
  };

  let invert = function (x, y, windy) {
    let mapLonDelta = windy.east - windy.west;
    let worldMapRadius = ((windy.width / rad2deg(mapLonDelta)) * 360) / (2 * Math.PI);
    let mapOffsetY = (worldMapRadius / 2) * Math.log((1 + Math.sin(windy.south)) / (1 - Math.sin(windy.south)));
    let equatorY = windy.height + mapOffsetY;
    let a = (equatorY - y) / worldMapRadius;

    let lat = (180 / Math.PI) * (2 * Math.atan(Math.exp(a)) - Math.PI / 2);
    let lon = rad2deg(windy.west) + (x / windy.width) * rad2deg(mapLonDelta);
    return [lon, lat];
  };

  let mercY = function (lat) {
    return Math.log(Math.tan(lat / 2 + Math.PI / 4));
  };

  let project = function (lat, lon, windy) {
    // both in radians, use deg2rad if neccessary
    let ymin = mercY(windy.south);
    let ymax = mercY(windy.north);
    let xFactor = windy.width / (windy.east - windy.west);
    let yFactor = windy.height / (ymax - ymin);

    let y1 = mercY(deg2rad(lat));
    let x = (deg2rad(lon) - windy.west) * xFactor;
    let y = (ymax - y1) * yFactor; // y points south
    return [x, y];
  };

  let interpolateField = function (grid, bounds, extent, callback) {
    let projection = {};
    let velocityScale = VELOCITY_SCALE;

    let columns = [];
    let x = bounds.x;

    function interpolateColumn(x) {
      let column = [];
      for (let y = bounds.y; y <= bounds.yMax; y += 2) {
        let coord = invert(x, y, extent);
        if (coord) {
          let λ = coord[0],
            φ = coord[1];
          if (isFinite(λ)) {
            let wind = grid.interpolate(λ, φ);
            if (wind) {
              wind = distort(projection, λ, φ, x, y, velocityScale, wind, extent);
              column[y + 1] = column[y] = wind;
            }
          }
        }
      }
      columns[x + 1] = columns[x] = column;
    }

    (function batchInterpolate() {
      let start = Date.now();
      while (x < bounds.width) {
        interpolateColumn(x);
        x += 2;
        if (Date.now() - start > 1000) {
          //MAX_TASK_TIME) {
          setTimeout(batchInterpolate, 25);
          return;
        }
      }
      createField(columns, bounds, callback);
    })();
  };

  let animate = function (bounds, field) {
    function windIntensityColorScale(step, maxWind) {
      result = ["#d73027", "#f46d43", "#fdae61", "#fee090", "#ffffbf", "#e0f3f8", "#abd9e9", "#74add1", "#4575b4"];
      result.indexFor = function (m) {
        // map wind speed to a style
        return Math.floor((Math.min(m, maxWind) / maxWind) * (result.length - 1));
      };
      return result;
    }

    let colorStyles = windIntensityColorScale(INTENSITY_SCALE_STEP, MAX_WIND_INTENSITY);
    let buckets = colorStyles.map(function () {
      return [];
    });

    let particleCount = Math.round(bounds.width * bounds.height * PARTICLE_MULTIPLIER);
    if (isMobile()) {
      particleCount *= PARTICLE_REDUCTION;
    }

    let fadeFillStyle = "rgba(0, 0, 0, 0.97)";

    let particles = [];
    for (let i = 0; i < particleCount; i++) {
      particles.push(field.randomize({ age: Math.floor(Math.random() * MAX_PARTICLE_AGE) + 0 }));
    }

    function evolve() {
      buckets.forEach(function (bucket) {
        bucket.length = 0;
      });
      particles.forEach(function (particle) {
        if (particle.age > MAX_PARTICLE_AGE) {
          field.randomize(particle).age = 0;
        }
        let x = particle.x;
        let y = particle.y;
        let v = field(x, y); // vector at current position
        let m = v[2];
        if (m === null) {
          particle.age = MAX_PARTICLE_AGE; // particle has escaped the grid, never to return...
        } else {
          let xt = x + v[0];
          let yt = y + v[1];
          if (field(xt, yt)[2] !== null) {
            // Path from (x,y) to (xt,yt) is visible, so add this particle to the appropriate draw bucket.
            particle.xt = xt;
            particle.yt = yt;
            buckets[colorStyles.indexFor(m)].push(particle);
          } else {
            // Particle isn't visible, but it still moves through the field.
            particle.x = xt;
            particle.y = yt;
          }
        }
        particle.age += 1;
      });
    }

    let g = params.canvas.getContext("2d");
    g.lineWidth = PARTICLE_LINE_WIDTH;
    console.log();
    g.fillStyle = fadeFillStyle;
    g.globalAlpha = 0.6;

    function draw() {
      // Fade existing particle trails.
      let prev = "lighter";
      g.globalCompositeOperation = "destination-in";
      g.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
      g.globalCompositeOperation = prev;
      g.globalAlpha = 0.9;

      // Draw new particle trails.
      buckets.forEach(function (bucket, i) {
        if (bucket.length > 0) {
          g.beginPath();
          g.strokeStyle = colorStyles[i];
          bucket.forEach(function (particle) {
            g.moveTo(particle.x, particle.y);
            g.lineTo(particle.xt, particle.yt);
            particle.x = particle.xt;
            particle.y = particle.yt;
          });
          g.stroke();
        }
      });
    }

    (function frame() {
      try {
        windy.timer = setTimeout(function () {
          requestAnimationFrame(frame);
          evolve();
          draw();
        }, 1000 / FRAME_RATE);
      } catch (e) {
        console.error(e);
      }
    })();
  };

  let start = function (bounds, width, height, extent, finalParams) {
    if (finalParams && finalParams.particleLineWidth) {
      PARTICLE_LINE_WIDTH = finalParams.particleLineWidth;
    }
    let mapBounds = {
      south: deg2rad(extent[0][1]),
      north: deg2rad(extent[1][1]),
      east: deg2rad(extent[1][0]),
      west: deg2rad(extent[0][0]),
      width: width,
      height: height
    };

    stop();

    // build grid
    buildGrid(params.data, function (grid) {
      // interpolateField
      interpolateField(grid, buildBounds(bounds, width, height), mapBounds, function (bounds, field) {
        // animate the canvas with random points
        windy.field = field;
        animate(bounds, field);
      });
    });
  };

  let stop = function () {
    if (windy.field) windy.field.release();
    if (windy.timer) clearTimeout(windy.timer);
  };

  let windy = {
    params: params,
    start: start,
    stop: stop
  };

  return windy;
};

// shim layer with setTimeout fallback
window.requestAnimationFrame = (function () {
  return (
    window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame ||
    window.mozRequestAnimationFrame ||
    window.oRequestAnimationFrame ||
    window.msRequestAnimationFrame ||
    function (callback) {
      window.setTimeout(callback, 1000 / 20);
    }
  );
})();
